#!/usr/bin/env python3
"""
kickback — a local, read-only-by-default companion for the Kickbacks.ai
VS Code/Cursor extension. Shows your status, live earnings, ad history and
derived economics in the terminal.

DESIGN PRINCIPLES (see `kickback about`):
  • Nothing about your device or account is baked in. Every value the tool
    uses is discovered at runtime ON YOUR MACHINE (keychain, your editor's
    local DB, your $HOME) or lives in an editable config file you control.
  • The only thing ever sent over the network is YOUR OWN access token plus
    the Claude Code version, to Kickbacks' OWN backend — the same server the
    official extension already talks to. Nothing goes anywhere else.
  • Safe by default: every feature that writes anything (sampler, notifications,
    token refresh) is OFF until you turn it on. Read-only status needs nothing.

No third-party Python packages required: crypto uses the `cryptography` module
if present, otherwise falls back to the system `openssl` (always on macOS).
"""
import json, os, re, sys, time, sqlite3, subprocess, hashlib, base64, shutil, urllib.request, urllib.error, urllib.parse

APP = "kickback"
VERSION = "0.1.6"
CONFIG_DIR = os.path.expanduser(os.path.join("~", ".config", APP))
CFG_PATH = os.path.join(CONFIG_DIR, "config.json")
SELF = os.path.abspath(__file__)
ZSHRC = os.path.expanduser("~/.zshrc")
SAMPLER_LABEL = "ai.kickbacks.sampler"
NOTIFY_LABEL = "ai.kickbacks.daily"
# Update channel — a tiny version file on our own GitHub Pages site (no GitHub
# API, no rate limits). Only contacted for `kickback update` (explicit) or when
# the opt-in `update_check` feature is on. Never carries a token or any user data.
UPDATE_MANIFEST_URL = "https://gabeperez.github.io/kickback-cli/latest.json"
INSTALL_ONELINER = "curl -fsSL https://gabeperez.github.io/kickback-cli/install.sh | bash"
FRESH_MS = 600_000

DEFAULT_CONFIG = {
    "version": 1,
    "network_enabled": True,        # allow talking to the Kickbacks backend at all
    "features": {
        "sampler": False,           # 60s launchd job that logs ad rotations + earnings
        "notifications": False,     # daily macOS notification with your earnings
        "token_refresh": False,     # RISKY: mint+persist a fresh token when editor is closed
        "autorewire": False,        # on each run, restore spinner/statusline keys if stripped
        "update_check": False,      # once/day, GET our version file + nudge if a newer CLI exists
    },
    "notify_hour": 21,
    "backend_base_url": "https://kickbacks-backend-gmdaqm2c7q-uw.a.run.app",
    "endpoints": {"earnings": "/v1/earnings", "portfolio": "/v1/portfolio", "refresh": "/v1/auth/refresh"},
    "vibe_ads_dir": "~/.vibe-ads",            # where the extension writes cli-ad.json / debug.log
    "claude_settings": "~/.claude/settings.json",
    "editors": [
        {"name": "Code", "keychain_service": "Code Safe Storage",
         "state_db": "~/Library/Application Support/Code/User/globalStorage/state.vscdb",
         "process_match": "Visual Studio Code.app"},
        {"name": "Cursor", "keychain_service": "Cursor Safe Storage",
         "state_db": "~/Library/Application Support/Cursor/User/globalStorage/state.vscdb",
         "process_match": "Cursor.app"},
    ],
}

# ---------- args / color --------------------------------------------------
ARGS = sys.argv[1:]
def flag(n): return n in ARGS
def positional(i, default=None):
    pos = [a for a in ARGS if not a.startswith("-")]
    return pos[i] if len(pos) > i else default
def opt(name, default=None):
    """Value of a `--name VALUE` or `--name=VALUE` option, else default."""
    for i, a in enumerate(ARGS):
        if a == name and i+1 < len(ARGS) and not ARGS[i+1].startswith("-"): return ARGS[i+1]
        if a.startswith(name+"="): return a.split("=", 1)[1]
    return default
NO_COLOR = flag("--plain") or not sys.stdout.isatty() or os.environ.get("NO_COLOR")
def c(code, s): return s if NO_COLOR else f"\033[{code}m{s}\033[0m"
DIM=lambda s:c("2",s); BOLD=lambda s:c("1",s); GREEN=lambda s:c("32",s)
RED=lambda s:c("31",s); YELLOW=lambda s:c("33",s); CYAN=lambda s:c("36",s)
def expand(p): return os.path.expanduser(p) if isinstance(p, str) else p
def emit(obj): print(json.dumps(obj, indent=2, default=str))   # one JSON writer for every --json path

# ---------- config --------------------------------------------------------
def _detect_initial_features():
    la = os.path.expanduser("~/Library/LaunchAgents")
    return {
        "sampler": os.path.exists(os.path.join(la, SAMPLER_LABEL+".plist")),
        "notifications": os.path.exists(os.path.join(la, NOTIFY_LABEL+".plist")),
        "token_refresh": False,
    }

def _deep_merge(base, over):
    out = dict(base)
    for k, v in (over or {}).items():
        if isinstance(v, dict) and isinstance(out.get(k), dict):
            out[k] = _deep_merge(out[k], v)
        else:
            out[k] = v
    return out

def _coerce_cfg(co):
    """Normalize types so a hand-edited config can't crash the tool. The config is
    'editable by hand' per the docs, so we tolerate wrong-typed values."""
    if not isinstance(co.get("features"), dict):
        co["features"] = dict(DEFAULT_CONFIG["features"])
    try:
        co["notify_hour"] = int(co.get("notify_hour", 21))
    except (ValueError, TypeError):
        co["notify_hour"] = DEFAULT_CONFIG["notify_hour"]
    if not (0 <= co["notify_hour"] <= 23):
        co["notify_hour"] = DEFAULT_CONFIG["notify_hour"]
    for k in ("backend_base_url", "vibe_ads_dir", "claude_settings"):
        if not isinstance(co.get(k), str):
            co[k] = DEFAULT_CONFIG[k]
    if not isinstance(co.get("endpoints"), dict):
        co["endpoints"] = dict(DEFAULT_CONFIG["endpoints"])
    return co

_CFG = None
def cfg():
    global _CFG
    if _CFG is not None: return _CFG
    if os.path.exists(CFG_PATH):
        try:
            _CFG = _coerce_cfg(_deep_merge(DEFAULT_CONFIG, json.load(open(CFG_PATH))))
        except Exception:
            _CFG = dict(DEFAULT_CONFIG)
    else:
        # first run: create config, reflecting any launchd jobs already present
        _CFG = dict(DEFAULT_CONFIG)
        _CFG["features"] = _detect_initial_features()
        save_cfg(_CFG)
    return _CFG

def save_cfg(co):
    global _CFG
    os.makedirs(CONFIG_DIR, exist_ok=True)
    with open(CFG_PATH, "w") as f: json.dump(co, f, indent=2)
    _CFG = co

def feature(name): return bool(cfg().get("features", {}).get(name))
def VA(): return expand(cfg()["vibe_ads_dir"])
def path_ad(): return os.path.join(VA(), "cli-ad.json")
def path_log(): return os.path.join(VA(), "debug.log")
def path_hist(): return os.path.join(VA(), "ad-history.jsonl")
def path_daily(): return os.path.join(VA(), "daily.json")
def BACKEND(): return cfg()["backend_base_url"]
def ep(k): return cfg()["endpoints"][k]

# ---------- crypto (cryptography OR openssl) ------------------------------
def _aes_cbc(data, key, decrypt):
    iv = b" " * 16
    try:
        from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
        from cryptography.hazmat.backends import default_backend
        ctx = Cipher(algorithms.AES(key), modes.CBC(iv), backend=default_backend())
        op = ctx.decryptor() if decrypt else ctx.encryptor()
        return op.update(data) + op.finalize()
    except ImportError:
        mode = "-d" if decrypt else "-e"
        r = subprocess.run(["openssl", "enc", "-aes-128-cbc", mode, "-nopad",
                            "-K", key.hex(), "-iv", iv.hex()],
                           input=data, capture_output=True)
        if r.returncode != 0:
            raise RuntimeError("openssl: " + r.stderr.decode()[:120])
        return r.stdout

def _key(svc):
    pw = subprocess.check_output(["security", "find-generic-password", "-s", svc, "-w"],
                                 stderr=subprocess.DEVNULL).strip()
    return hashlib.pbkdf2_hmac("sha1", pw, b"saltysalt", 1003, 16)
def _dec(buf, k):
    pt = _aes_cbc(bytes(buf)[3:], k, True)
    return pt[:-pt[-1]]
def _enc(s, k):
    data = s.encode() if isinstance(s, str) else s
    pad = 16 - (len(data) % 16); data += bytes([pad]) * pad
    return b"v10" + _aes_cbc(data, k, False)

def _read_secret(db, which):
    db = expand(db)
    if not os.path.exists(db): return None
    con = sqlite3.connect(f"file:{db}?mode=ro", uri=True)
    try:
        row = con.execute("SELECT value FROM ItemTable WHERE key LIKE ?",
                          (f'secret://%kickbacks.{which}%',)).fetchone()
    finally:
        con.close()
    return json.loads(row[0])["data"] if row else None
def _jwt_claims(tok):
    try:
        pl = tok.split(".")[1]; pl += "=" * (-len(pl) % 4)
        return json.loads(base64.urlsafe_b64decode(pl))
    except Exception: return {}
def _running(proc):
    try: return subprocess.run(["pgrep", "-f", proc], capture_output=True).returncode == 0
    except Exception: return False

# ---------- editor token sources -----------------------------------------
def token_sources():
    if _demo_on(): return [_demo_source()]
    out = []
    for ed in cfg()["editors"]:
        try:
            buf = _read_secret(ed["state_db"], "access")
            if not buf: continue
            k = _key(ed["keychain_service"])
            tok = _dec(buf, k).decode("utf-8", "replace")
            cl = _jwt_claims(tok)
            out.append({"name": ed["name"], "token": tok, "claims": cl,
                        "exp": cl.get("exp"), "running": _running(ed["process_match"]),
                        "key": k, "ed": ed})
        except Exception:
            continue
    return out

def best_source():
    srcs = token_sources()
    if not srcs: return None, "no signed-in editor found"
    srcs.sort(key=lambda s: s.get("exp") or 0, reverse=True)
    best = srcs[0]; now = int(time.time())
    if feature("token_refresh") and best.get("exp") and best["exp"] - now < 120 and not best["running"]:
        nt = _do_refresh(best, quiet=True)
        if nt: best["token"] = nt
    return best, None

# ---------- backend -------------------------------------------------------
def _get(path, token):
    req = urllib.request.Request(BACKEND() + path, headers={"authorization": f"Bearer {token}"})
    with urllib.request.urlopen(req, timeout=12) as r: return json.loads(r.read())
def _post(path, body):
    req = urllib.request.Request(BACKEND() + path, data=json.dumps(body).encode(),
                                 method="POST", headers={"content-type": "application/json"})
    with urllib.request.urlopen(req, timeout=12) as r: return json.loads(r.read())

def _do_refresh(src, quiet=False):
    """Only called when token_refresh is enabled AND the editor is closed.
    Rotates the refresh token server-side, so we back up and persist BOTH new
    tokens into the editor's store, keeping its sign-in valid."""
    if not feature("token_refresh"):
        if not quiet: print("token_refresh is disabled — enable with `kickback enable token_refresh`")
        return None
    try:
        rbuf = _read_secret(src["ed"]["state_db"], "refresh")
        if not rbuf: return None
        rt = _dec(rbuf, src["key"]).decode("utf-8", "replace")
        j = _post(ep("refresh"), {"refresh_token": rt})
        new_at = j.get("access_token")
        if not new_at: return None
        os.makedirs(VA(), exist_ok=True)
        try: os.chmod(VA(), 0o700)  # owner-only (holds encrypted token backups)
        except OSError: pass
        bak = os.path.join(VA(), f"token-backup-{src['name']}-{int(time.time()*1000)}.json")
        fd = os.open(bak, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)  # owner-only
        with os.fdopen(fd, "w") as f:
            json.dump({"access": _read_secret(src["ed"]["state_db"], "access"), "refresh": rbuf}, f)
        _store_secret(src, "access", new_at)
        if j.get("refresh_token"): _store_secret(src, "refresh", j["refresh_token"])
        if not quiet: print(f"refreshed {src['name']} (backup: {os.path.basename(bak)})")
        return new_at
    except Exception as ex:
        if not quiet: print("refresh failed:", type(ex).__name__)
        return None

def _store_secret(src, which, token_str):
    val = json.dumps({"type": "Buffer", "data": list(_enc(token_str, src["key"]))})
    con = sqlite3.connect(expand(src["ed"]["state_db"]), timeout=5)
    try:
        con.execute("UPDATE ItemTable SET value=? WHERE key LIKE ?", (val, f'secret://%kickbacks.{which}%'))
        con.commit()
    finally:
        con.close()

def fetch_live(ccver):
    """(earnings|None, portfolio|None, err|None, source|None)"""
    if _demo_on():
        return _demo_earnings(), _demo_portfolio(), None, _demo_source()
    if not cfg().get("network_enabled", True):
        return None, None, "network disabled", None
    try:
        src, err = best_source()
        if not src: return None, None, err, None
        v = ccver or ""
        e = _get(f"{ep('earnings')}?claude_code_version={v}", src["token"])
        p = None
        try: p = _get(f"{ep('portfolio')}?claude_code_version={v}", src["token"])
        except Exception: pass
        return e, p, None, src
    except urllib.error.HTTPError as ex: return None, None, f"HTTP {ex.code}", None
    except subprocess.CalledProcessError: return None, None, "keychain denied", None
    except Exception as ex: return None, None, type(ex).__name__, None

# ---------- small helpers (json read + money formatting) -----------------
def read_json(p):
    try:
        with open(p) as f: return json.load(f)
    except Exception: return None
def usd(m):
    try:
        v = float(m)/1e6
        return f"${v:,.2f}" if abs(v) >= 1 else f"${v:.4f}"
    except Exception: return "$?"

# ---------- demo mode (KICKBACK_DEMO=1) -----------------------------------
# Coherent illustrative data for screenshots / marketing — touches no
# keychain, token, network, or real history.
def _demo_on(): return bool(os.environ.get("KICKBACK_DEMO"))
_DEMO_ADS = [
    ("Sundial — trustworthy AI data engineering + analysis", "https://sundial.ai/"),
    ("Inflowpay — global sales, 50% less fees, 10% more conversions", "https://inflowpay.com/"),
    ("Viktor — your AI employee in Slack", "https://heyviktor.com/"),
    ("AccessGrid — unlock anything with Apple Wallet", "https://accessgrid.com/"),
    ("here.now — publish your agent for free hosting", "https://here.now/"),
]
def _demo_earnings():
    life = float(os.environ.get("KICKBACK_DEMO_LIFETIME", "312.40"))
    today = float(os.environ.get("KICKBACK_DEMO_TODAY", "22.00"))
    return {"lifetime_micros": int(life*1e6), "today_micros": int(today*1e6),
            "lifetime_usd": f"{life:.2f}", "today_usd": f"{today:.2f}", "cap": None, "blocked": False}
def _demo_portfolio():
    a = _DEMO_ADS[0]
    return {"ads": [{"title_text": a[0], "ad_id": "demo", "campaign_id": "demo-q4",
                     "weight": 1.0, "click_url": a[1]}],
            "view_threshold_seconds": 15, "rotation_interval_seconds": 60, "ttl_seconds": 60, "balances": {}}
def _demo_source():
    return {"name": "Code", "token": "demo", "claims": {"email": "you@example.com"},
            "exp": int(time.time()) + 3480, "running": True, "key": b"", "ed": None}
def _demo_history():
    rows = []; cur = 240_000000; ts = int(time.time()*1000) - 9*3600*1000
    for i, inc in enumerate([6, 9, 5, 12, 7, 8, 11, 6, 4, 4]):
        title = _DEMO_ADS[i % len(_DEMO_ADS)][0]
        rows.append({"ts": ts + i*3600*1000, "title": title, "url": "",
                     "micros": cur, "weight": round(0.6 + 0.05*i, 2)})
        cur += inc*1_000000
    return rows
def _demo_daily():
    d = {}; cum = 0
    for i, v in enumerate([18, 24, 29, 31, 42, 38, 35, 40, 33, 22]):
        day = time.strftime("%Y-%m-%d", time.localtime(time.time() - (9 - i)*86400))
        d[day] = {"life_min": cum*1_000000, "life_max": (cum+v)*1_000000,
                  "today_max": v*1_000000, "first": 0, "last": 0}
        cum += v
    return d
def _demo_status():
    a = _DEMO_ADS[0]
    return {"verdict": "active", "_vc": GREEN, "health": "ok", "cc_version": "2.1.177",
            "ad_text": a[0], "ad_url": a[1], "ad_age": 41.0, "ad_fresh": True,
            "spinner_on": True, "statusline_on": True}

# ---------- on-disk status (extension's debug.log / cli-ad.json / settings) -
def last_session_state():
    state, last = {}, ""
    try:
        with open(path_log(), encoding="utf-8", errors="replace") as f:
            for line in f:
                line = line.rstrip("\n")
                if not line: continue
                last = line
                if "session.state" in line:
                    m = re.search(r"session\.state\s+(\{.*\})", line)
                    if m:
                        try: state = json.loads(m.group(1))
                        except Exception: pass
    except FileNotFoundError: pass
    return state, last

def gather():
    if _demo_on(): return _demo_status()
    state, _ = last_session_state()
    ad = read_json(path_ad()) or {}
    st = read_json(expand(cfg()["claude_settings"])) or {}
    age = (time.time()*1000 - ad["ts"])/1000.0 if isinstance(ad.get("ts"), (int, float)) else None
    sv = st.get("spinnerVerbs") or {}; sl = st.get("statusLine") or {}
    signed = state.get("signedIn")
    if signed is False: v, vc = "sign-in needed", RED
    elif state.get("killed"): v, vc = "killed", RED
    elif signed and state.get("injectionOn") and state.get("authHealthy") == "ok": v, vc = "active", GREEN
    elif signed and not state.get("injectionOn"): v, vc = "idle / off", YELLOW
    elif signed: v, vc = "signed in", YELLOW
    else: v, vc = "unknown", YELLOW
    return {"verdict": v, "_vc": vc, "health": state.get("authHealthy"),
            "cc_version": state.get("ccVersion"), "ad_text": ad.get("adText"),
            "ad_url": ad.get("clickUrl"), "ad_age": age,
            "ad_fresh": age is not None and age <= FRESH_MS/1000.0,
            "spinner_on": bool(sv.get("verbs")),
            "statusline_on": "vibe-ads-statusline" in (sl.get("command") or "")}

# ---------- history + daily -----------------------------------------------
def read_hist():
    if _demo_on(): return _demo_history()
    rows = []
    try:
        with open(path_hist()) as f:
            for line in f:
                line = line.strip()
                if line:
                    try: rows.append(json.loads(line))
                    except Exception: pass
    except FileNotFoundError: pass
    return rows

def update_daily(e):
    if not e or e.get("lifetime_micros") is None: return
    life = e["lifetime_micros"]; today = e.get("today_micros") or 0
    d = read_json(path_daily()) or {}
    key = time.strftime("%Y-%m-%d", time.localtime())
    rec = d.get(key) or {"life_min": life, "life_max": life, "today_max": today,
                         "first": int(time.time()*1000), "last": int(time.time()*1000)}
    rec["life_min"] = min(rec["life_min"], life); rec["life_max"] = max(rec["life_max"], life)
    rec["today_max"] = max(rec.get("today_max", 0), today); rec["last"] = int(time.time()*1000)
    d[key] = rec
    with open(path_daily(), "w") as f: json.dump(d, f)
def day_earned(rec): return max(rec.get("today_max", 0), rec.get("life_max", 0) - rec.get("life_min", 0))

def log_ad(quiet=False, prefetched=None):
    if _demo_on(): return                      # never write logs in demo mode
    ad = read_json(path_ad()) or {}; title = ad.get("adText")
    if not title:
        if not quiet: print("no current ad to log")
        return
    rows = read_hist()
    rec = {"ts": int(time.time()*1000), "title": title, "url": ad.get("clickUrl") or ""}
    if prefetched is not None:                  # reuse the caller's fetch (no 2nd keychain/network hit)
        e, p = prefetched
    else:
        state, _ = last_session_state()
        e, p, _err, _src = fetch_live(state.get("ccVersion"))
    if e: rec["micros"] = e.get("lifetime_micros")
    if p:
        for a in (p.get("ads") or []):
            if a.get("title_text") == title:
                rec["ad_id"] = a.get("ad_id"); rec["campaign_id"] = a.get("campaign_id"); rec["weight"] = a.get("weight"); break
    update_daily(e)
    last_title = rows[-1].get("title") if rows else None
    if title == last_title:
        if rec.get("micros") is not None and rec["micros"] != rows[-1].get("micros"):
            with open(path_hist(), "a") as f: f.write(json.dumps(rec)+"\n")
            if not quiet: print("micros datapoint added")
        elif not quiet: print("unchanged — not logged")
        return
    with open(path_hist(), "a") as f: f.write(json.dumps(rec)+"\n")
    if not quiet: print("logged:", title[:55], "·", usd(rec.get("micros")) if rec.get("micros") is not None else "no $")

def attribute(rows):
    per = {}; pts = [(r["ts"], r["micros"], r.get("title")) for r in rows
                     if isinstance(r.get("micros"), (int, float)) and isinstance(r.get("ts"), (int, float))]
    for i in range(len(pts)-1):
        d = pts[i+1][1] - pts[i][1]
        if d > 0: per[pts[i][2]] = per.get(pts[i][2], 0) + d
    return per, pts

SPARK = "▁▂▃▄▅▆▇█"
def sparkline(vals):
    if not vals: return ""
    lo, hi = min(vals), max(vals)
    if hi <= lo: return SPARK[0]*len(vals)
    return "".join(SPARK[min(7, int((v-lo)/(hi-lo)*7+0.5))] for v in vals)
def when(ms): return time.strftime("%b %d %H:%M", time.localtime(ms/1000))

# ---------- views ---------------------------------------------------------
def show_history():
    rows = read_hist()
    per, _ = attribute(rows); agg = {}
    for r in rows:
        ts = r.get("ts")
        if not isinstance(ts, (int, float)): continue      # skip malformed/hand-corrupted rows
        t = r.get("title", "?"); a = agg.setdefault(t, {"count": 0, "first": ts, "last": ts, "weight": r.get("weight")})
        a["count"] += 1; a["first"] = min(a["first"], ts); a["last"] = max(a["last"], ts)
    ranked = sorted(agg.items(), key=lambda kv: per.get(kv[0], 0) or kv[1]["count"], reverse=True)
    if flag("--json"):
        return emit({
            "distinct": len(agg), "samples": len(rows), "source": "derived",
            "note": "$ is derived from lifetime deltas, not billed",
            "ads": [{"title": t, "count": a["count"], "first_ms": a["first"], "last_ms": a["last"],
                     "weight": a.get("weight"),
                     "earned_micros": int(per[t]) if per.get(t) else None,
                     "earned_usd": round(per[t]/1e6, 6) if per.get(t) else None} for t, a in ranked]})
    if not rows: print("No ad history yet. Enable the sampler: `kickback enable sampler`."); return
    has = any(per.values())
    print(" "+BOLD("Kickbacks — ads seen")+DIM(f"   ({len(agg)} distinct · {len(rows)} samples)"))
    print("  "+DIM("n× times seen · ")+GREEN("$")+DIM(" est. earned (approx) · w= ad weight · first → last"))
    print(" "+DIM("─"*66))
    for t, a in sorted(agg.items(), key=lambda kv: per.get(kv[0], 0) if has else kv[1]["count"], reverse=True):
        money = "  "+GREEN(usd(per[t])) if per.get(t) else ""
        w = DIM(f"  w={a['weight']}") if a.get("weight") is not None else ""
        print(f"  {BOLD(str(a['count']).rjust(4))}×{money}  {t[:46]}{w}")
        print(f"        {DIM(when(a['first'])+'  →  '+when(a['last']))}")
    print(" "+DIM("─"*66))
    print(DIM("  $ is derived, not billed — run `kickback earnings` for your real total."))

# ---------- daily + weekly / monthly / yearly rollups ---------------------
# All views roll the same per-day buckets (daily.json) up to a chosen grain,
# so they inherit daily's caveat: the backend keeps no historical record, so a
# period only counts the days the sampler actually observed. Weeks are ISO
# (Mon-start), labelled YYYY-Www; a partial current period is normal.
_PERIOD_FMT  = {"daily": None, "weekly": "%G-W%V", "monthly": "%Y-%m", "yearly": "%Y"}
_PERIOD_META = {"daily": ("daily earnings", 14),        # (title, rows to list)
                "weekly": ("weekly earnings", 12),
                "monthly": ("monthly earnings", 12),
                "yearly": ("yearly earnings", 10)}
def _earnings_buckets(period):
    """{period_key: micros} rolled up from daily.json — canonical micros, never billed.
    period='daily' keeps raw YYYY-MM-DD keys; coarser grains group by _PERIOD_FMT."""
    d = _demo_daily() if _demo_on() else (read_json(path_daily()) or {})
    if not isinstance(d, dict): return {}                  # tolerate a hand-corrupted daily.json
    fmt = _PERIOD_FMT[period]; buckets = {}
    for day, rec in d.items():
        if not isinstance(rec, dict): continue
        if fmt is None:
            pk = day
        else:
            try: pk = time.strftime(fmt, time.strptime(day, "%Y-%m-%d"))
            except ValueError: continue      # skip any malformed daily key
        buckets[pk] = buckets.get(pk, 0) + day_earned(rec)
    return buckets

def _emit_earnings_json(period, buckets):
    """Machine-readable rollup. micros is the canonical integer (1e6 = $1);
    usd is a lossless convenience float. Valid JSON even when empty."""
    keys = sorted(buckets); total = int(sum(buckets.values()))
    emit({
        "period": period,
        "buckets": [{"key": k, "micros": int(buckets[k]), "usd": round(buckets[k]/1e6, 6)} for k in keys],
        "total_micros": total,
        "total_usd": round(total/1e6, 6),
        "source": "derived",            # rolled up from daily.json, not billed
        "backend_history": False,       # backend keeps no historical record
    })

def show_period(period="daily"):
    title, tail = _PERIOD_META[period]
    buckets = _earnings_buckets(period)
    if flag("--json"): return _emit_earnings_json(period, buckets)
    if not buckets:
        print(f"No {title} yet — accrues once the sampler runs across a day (`kickback enable sampler`)."); return
    keys = sorted(buckets); vals = [buckets[k] for k in keys]; w = max(len(k) for k in keys)
    note = "(per-day = max(today, lifetime Δ); backend keeps no daily record)" if period == "daily" \
        else "(rolled up from daily; backend keeps no historical record)"
    print(" "+BOLD(f"Kickbacks — {title}")); print(" "+DIM("─"*52))
    if len(vals) > 1: print("  "+CYAN(sparkline(vals))+DIM(f"   {keys[0]} → {keys[-1]}"))
    mx = max(vals) or 1
    for k in keys[-tail:]:
        bar = "█"*min(30, int(buckets[k]/mx*30))
        print(f"  {k.ljust(w)}  {GREEN(usd(buckets[k]).rjust(9))}  {DIM(bar)}")
    print(" "+DIM("─"*52))
    print(f"  total observed: {GREEN(usd(sum(vals)))}"+DIM("  "+note))
def show_daily(): return show_period("daily")

def show_auth():
    srcs = token_sources()
    now = int(time.time())
    if flag("--json"):
        return emit({
            "signed_in": bool(srcs),
            "token_refresh": feature("token_refresh"),
            "editors": [{"name": s["name"], "email": s["claims"].get("email"),
                         "exp": s.get("exp"),
                         "seconds_left": (s["exp"]-now) if s.get("exp") else None,
                         "valid": bool(s.get("exp") and s["exp"] > now),
                         "running": bool(s["running"])} for s in srcs]})
    if not srcs: print("No signed-in editor found (VS Code / Cursor)."); return
    print(" "+BOLD("Kickbacks — auth")); print(" "+DIM("─"*52))
    for s in srcs:
        exp = s.get("exp"); left = f"{(exp-now)/60:.0f}m left" if exp else "?"
        col = GREEN if (exp and exp > now) else RED
        print(f"  {BOLD(s['name'].ljust(7))} {col(left.rjust(9))}  {DIM(s['claims'].get('email','?')+' · '+('running' if s['running'] else 'closed'))}")
    print(" "+DIM("─"*52))
    rf = "on" if feature("token_refresh") else "off"
    print(DIM(f"  token_refresh: {rf} — when on, refreshes only while the editor is closed."))

def show_earnings():
    state, _ = last_session_state()
    e, p, err, src = fetch_live(state.get("ccVersion"))
    rows = read_hist()
    pts = [(r["ts"], r["micros"]) for r in rows if isinstance(r.get("micros"), (int, float))]
    velocity = None
    if len(pts) >= 2 and pts[-1][1] > pts[0][1]:
        span_h = (pts[-1][0]-pts[0][0])/3.6e6
        if span_h > 0:
            per_h = (pts[-1][1]-pts[0][1])/span_h
            velocity = {"per_hour_micros": int(per_h), "per_day_micros": int(per_h*24), "span_hours": round(span_h, 2)}
    per, _ = attribute(rows); rota = sum(1 for r in rows if r.get("title")); tot = sum(per.values())
    eff_rate = int(tot/rota) if (tot > 0 and rota > 0) else None
    attributed_pct = round(tot/max(1, e.get("lifetime_micros") or 0)*100, 1) if (e and tot > 0) else None
    if flag("--json"):
        return emit({
            "live": bool(e), "error": None if e else (err or "unavailable"),
            "lifetime_micros": e.get("lifetime_micros") if e else None,
            "today_micros": e.get("today_micros") if e else None,
            "cap": e.get("cap") if e else None,
            "blocked": bool(e.get("blocked")) if e else None,
            "velocity": velocity,
            "eff_rate_micros_per_rotation": eff_rate,
            "attributed_pct": attributed_pct,
            "view_threshold_seconds": p.get("view_threshold_seconds") if p else None,
            "rotation_interval_seconds": p.get("rotation_interval_seconds") if p else None,
            "ttl_seconds": p.get("ttl_seconds") if p else None,
            "token_source": (src or {}).get("name")})
    print(" "+BOLD("Kickbacks — earnings")); print(" "+DIM("─"*52))
    if e:
        print(f"  lifetime    {GREEN(BOLD(usd(e.get('lifetime_micros',0))))}")
        print(f"  today       {GREEN(usd(e.get('today_micros',0)))}")
        if e.get("cap"): print(f"  cap         {e['cap']}")
        if e.get("blocked"): print("  "+RED("account blocked"))
    else:
        print(f"  live        {DIM('unavailable ('+(err or '?')+')')}")
    if velocity:
        print(f"\n  velocity    {usd(velocity['per_hour_micros'])}/hr  ·  ~{usd(velocity['per_day_micros'])}/day"+DIM(f"   (over {velocity['span_hours']:.1f}h)"))
    if eff_rate is not None: print(f"  eff. rate   ~{usd(eff_rate)} per rotation"+DIM("  (your share)"))
    if attributed_pct is not None:
        print(f"  attributed  {attributed_pct:.0f}%"+DIM(" of lifetime mapped to specific ads"))
    if p:
        print(f"\n  view thresh {DIM(str(p.get('view_threshold_seconds','?'))+'s to count an impression')}")
        print(f"  rotation    {DIM(str(p.get('rotation_interval_seconds','?'))+'s · ttl '+str(p.get('ttl_seconds','?'))+'s')}")
    if src: print(f"  source      {DIM(src['name']+' token')}")
    print(" "+DIM("─"*52))
    print(DIM("  CPM / advertiser cost is server-side only and never sent to clients."))

def fmt_age(s):
    if s is None: return "no cache"
    if s < 90: return f"{s:.0f}s ago"
    if s < 5400: return f"{s/60:.0f}m ago"
    return f"{s/3600:.1f}h ago"

def render(d, e, p, err, src):
    L = [" "+BOLD("Kickbacks.ai"), " "+DIM("─"*52)]
    L.append(f"  status      {d['_vc'](BOLD(d['verdict']))}")
    if d["health"]: L.append(f"  health      {d['health']}")
    if d["cc_version"]: L.append(f"  CC version  {d['cc_version']}")
    L.append("")
    if e:
        L.append(f"  earned      {GREEN(BOLD(usd(e.get('lifetime_micros',0))))} lifetime  ·  {GREEN(usd(e.get('today_micros',0)))} today")
        if e.get("blocked"): L.append("  "+RED("account blocked"))
    elif err == "network disabled":
        L.append(f"  earned      {DIM('— network off (kickback enable network)')}")
    else:
        L.append(f"  earned      {DIM('— live fetch unavailable ('+(err or '?')+')')}")
    L.append("")
    if d["ad_text"]:
        L.append(f"  current ad  {d['ad_text']}")
        if d["ad_url"]: L.append(f"              {DIM('→ '+d['ad_url'])}")
        warm = GREEN("fresh ✓") if d["ad_fresh"] else YELLOW("stale (editor closed)")
        L.append(f"  ad cache    {fmt_age(d['ad_age'])} · {warm}")
    else:
        L.append(f"  current ad  {DIM('none cached')}")
    if p:
        L.append(f"  rules       {DIM(str(p.get('view_threshold_seconds','?'))+'s to count · rotates '+str(p.get('rotation_interval_seconds','?'))+'s')}")
    L.append("")
    L.append(f"  spinner     {GREEN('replace ✓') if d['spinner_on'] else RED('not set')}")
    L.append(f"  statusline  {GREEN('wired ✓') if d['statusline_on'] else RED('not set')}")
    if src and src.get("exp"):
        left = (src["exp"]-int(time.time()))/60
        L.append(f"  token       {(GREEN(f'{left:.0f}m') if left>2 else YELLOW('expiring'))} {DIM('('+src['name']+')')}")
    L.append(" "+DIM("─"*52))
    L.append(DIM("  kickback earnings · history · daily · about · doctor"))
    return "\n".join(L)

# ---------- config / features ---------------------------------------------
FEATURE_HELP = {
    "sampler": "60s background job (launchd) that logs each ad rotation + earnings, building your history & daily charts. Writes only to your local data dir.",
    "notifications": "daily macOS notification with your earnings + top ad, at the hour in config (notify_hour).",
    "token_refresh": "RISKY: when your editor is CLOSED and the token is about to expire, mint a fresh one and write it back into the editor's local store (a backup is saved first). Off = earnings just show 'unavailable' until you open the editor.",
    "autorewire": "on the 60s sampler tick, restore the spinner/statusline keys in ~/.claude/settings.json if another tool (Claude Code, hooks) stripped them while the extension is serving. Only-if-missing, atomic, only those two keys. Needs `sampler` on (it's the timer). Off = run `kickback rewire` by hand.",
    "update_check": "once a day, GET a small version file from our GitHub Pages site (gabeperez.github.io) and print a one-line nudge if a newer CLI is out. This is the ONLY thing the tool ever sends anywhere other than the Kickbacks backend — and it carries no token, account, or personal data. Off = check yourself with `kickback update`.",
    "network": "allow the tool to contact the Kickbacks backend at all. Off = fully offline; only on-disk status is shown, no earnings.",
}

def show_config():
    co = cfg()
    if flag("--json"): return emit({"config_path": CFG_PATH, **co})
    print(" "+BOLD("kickback config")+DIM(f"   ({CFG_PATH})")); print(" "+DIM("─"*60))
    print(f"  network        {GREEN('on') if co.get('network_enabled') else RED('off')}   {DIM('contact Kickbacks backend')}")
    for k in ("sampler", "notifications", "token_refresh", "autorewire", "update_check"):
        on = feature(k)
        tag = (GREEN("on ") if on else DIM("off"))
        risk = {"token_refresh": RED("  ⚠ writes to editor store"),
                "autorewire": DIM("  writes settings.json"),
                "update_check": DIM("  GETs github.io version file")}.get(k, "")
        print(f"  {k.ljust(14)} {tag}{risk}")
    print(" "+DIM("─"*60))
    print(f"  backend        {DIM(co['backend_base_url'])}")
    print(f"  data dir       {DIM(co['vibe_ads_dir'])}")
    print(f"  notify_hour    {DIM(str(co.get('notify_hour', 21))+':00  (daily notification time)')}")
    print(" "+DIM("─"*60))
    print(DIM("  details:  ")+"kickback config <feature>"+DIM("     what it does + how to change it"))
    print(DIM("  toggle:   ")+"kickback enable/disable <feature>")
    print(DIM("  set:      ")+"kickback config set <key> <value>"+DIM("   ·  open file: ")+"kickback config edit")

# Scalar (non-feature) keys editable via `kickback config set`.
_EDITABLE = {"notify_hour": "integer 0–23 (daily notification time)",
             "backend_base_url": "URL of the Kickbacks backend",
             "vibe_ads_dir": "dir the extension writes cli-ad.json / debug.log",
             "claude_settings": "path to ~/.claude/settings.json"}

def config_detail(name):
    """`kickback config <feature|key>` — full context for one setting + how to change it."""
    import textwrap
    co = cfg()
    if name in FEATURE_HELP:
        on = co.get("network_enabled", True) if name == "network" else feature(name)
        state = GREEN("on") if on else DIM("off")
        print(" "+BOLD(f"kickback config · {name}")+f"   [{state}]"); print(" "+DIM("─"*66))
        for line in textwrap.wrap(FEATURE_HELP[name], 64): print("  "+line)
        print(" "+DIM("─"*66))
        verb = "disable" if on else "enable"
        print(DIM("  change:  ")+f"kickback {verb} {name}")
        return
    if name in co:
        print(f"  {BOLD(name)} = {json.dumps(co[name])}")
        hint = _EDITABLE.get(name)
        if hint: print(DIM(f"  {hint}")); print(DIM("  change:  ")+f"kickback config set {name} <value>")
        else: print(DIM("  change:  ")+"kickback config edit")
        return
    print(f"unknown feature/key '{name}'.")
    print(DIM("  features: ")+", ".join(FEATURE_HELP))
    print(DIM("  keys:     ")+", ".join(_EDITABLE))

def config_edit():
    """Open the config file in $EDITOR (falls back to a sensible default)."""
    import shlex
    save_cfg(cfg())                       # make sure the file exists first
    editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") or "nano"
    try:
        subprocess.run(shlex.split(editor) + [CFG_PATH])
    except Exception as ex:
        print(RED(f"couldn't launch editor '{editor}' ({type(ex).__name__})."))
        print(DIM("  edit by hand:  ")+CFG_PATH)

def config_get(key):
    co = cfg()
    if key and key in co:
        if flag("--json"): return emit({key: co[key]})
        print(json.dumps(co[key])); return
    if flag("--json"): return emit({"key": key, "ok": False, "error": "unknown key"})
    print(f"unknown key '{key or ''}'."); print(DIM("  see all:  ")+"kickback config --json")

def config_set(key, value):
    def _err(msg):
        if flag("--json"): return emit({"key": key, "ok": False, "error": msg, "editable": list(_EDITABLE)})
        print(RED(msg) if key else "usage:  kickback config set <key> <value>")
        print(DIM("  editable keys:  ")+", ".join(_EDITABLE))
    if not key or value is None:
        return _err("usage: config set <key> <value>")
    if key in ("network", "sampler", "notifications", "token_refresh", "autorewire", "update_check"):
        if flag("--json"): return emit({"key": key, "ok": False, "error": "feature — use enable/disable"})
        print(f"'{key}' is a feature — toggle it:  "+f"kickback enable {key}"+DIM("  /  ")+f"kickback disable {key}"); return
    if key not in _EDITABLE:
        return _err(f"'{key}' isn't a settable scalar")
    co = cfg()
    if key == "notify_hour":
        try: v = int(value)
        except (ValueError, TypeError): return _err("notify_hour must be an integer 0–23")
        if not (0 <= v <= 23): return _err("notify_hour must be between 0 and 23")
        co[key] = v
    else:
        co[key] = value
    save_cfg(co)
    if flag("--json"): return emit({"key": key, "value": co[key], "ok": True})
    print(GREEN("✓ ")+f"{key} = {json.dumps(co[key])}")

def set_feature(name, on):
    co = cfg()
    if name not in ("sampler", "notifications", "token_refresh", "autorewire", "update_check", "network"):
        if flag("--json"): return emit({"feature": name, "ok": False, "error": "unknown feature",
                                        "options": ["network", "sampler", "notifications", "token_refresh", "autorewire", "update_check"]})
        print(f"unknown feature '{name}'. Options: sampler, notifications, token_refresh, autorewire, update_check, network."); return
    if name == "network":
        co["network_enabled"] = on; save_cfg(co)
    else:
        co["features"][name] = on; save_cfg(co)
        import io, contextlib
        cm = contextlib.redirect_stdout(io.StringIO()) if flag("--json") else contextlib.nullcontext()
        with cm:                                 # keep launchd-installer text out of the JSON stream
            if name == "sampler":
                install_sampler() if on else uninstall_sampler()
            elif name == "notifications":
                install_notify() if on else uninstall_notify()
    if flag("--json"): return emit({"feature": name, "enabled": on, "ok": True})
    if name == "network":
        print(f"network {'enabled' if on else 'disabled'}."); return
    if name == "token_refresh" and on:
        print(RED("  ⚠ token_refresh enabled.")+" It only acts when the editor is CLOSED, backs up")
        print("    your tokens first, and writes the rotated token back so sign-in stays valid.")
    elif name == "autorewire" and on and not feature("sampler"):
        print(DIM("  note: autorewire fires on the 60s sampler tick — enable it too:  kickback enable sampler"))
    print(f"{name} {'enabled' if on else 'disabled'}.")

def cmd_about():
    print(BOLD("kickback — what it does, what it touches, what could break\n"))
    print(BOLD("What it is"))
    print("  A local companion to the Kickbacks.ai editor extension. It reads the")
    print("  state that extension already stores on your machine and (optionally)")
    print("  asks Kickbacks' own backend for your earnings — then shows it in your")
    print("  terminal. It is not affiliated with Kickbacks.\n")
    print(BOLD("What it reads (all local, all yours)"))
    print("  • "+DIM(cfg()['vibe_ads_dir']+"/cli-ad.json, debug.log")+"  — current ad + status the extension wrote")
    print("  • "+DIM(cfg()['claude_settings'])+"  — to confirm the spinner/statusline are wired")
    print("  • your editor's local DB (state.vscdb) — to read YOUR Kickbacks access token")
    print("  • your macOS Keychain — the key that decrypts that token (same as the editor uses)\n")
    print(BOLD("What it sends over the network"))
    print("  • ONLY when network is on: your own access token + Claude Code version,")
    print("    to Kickbacks' OWN backend ("+DIM(cfg()['backend_base_url'])+").")
    print("  • If you opt into "+BOLD("update_check")+": a once-a-day GET to our GitHub Pages")
    print("    site for a version number — no token, account, or personal data attached.")
    print("  • Nothing else is sent to the author or any third party. Ever.\n")
    print(BOLD("What it never does"))
    print("  • read your code, prompts, or completions  • move money or click ads")
    print("  • bake in your account, paths, or device — all discovered at runtime\n")
    print(BOLD("Safe by default — features you opt into"))
    for k in ("network", "sampler", "notifications", "token_refresh", "autorewire", "update_check"):
        print(f"  • {BOLD(k)}: {FEATURE_HELP[k]}")
    print()
    print(BOLD("What could change or break it")+DIM("  (it degrades gracefully — never crashes)"))
    print("  • Extension update changes the token format/keys  → earnings show 'unavailable'")
    print("  • Backend endpoint/URL changes                    → 'HTTP 4xx/5xx'; edit backend_base_url in config")
    print("  • macOS Keychain scheme changes                   → 'keychain denied'")
    print("  • You sign out / token expires with editor closed → 'unavailable' until you reopen it")
    print("  All of these only affect the EARNINGS numbers; on-disk status keeps working.\n")
    print(DIM("  Run `kickback doctor` for a live check, `kickback config` for current toggles."))

def _doctor_report():
    """Structured diagnostics — one source of truth for both the text and --json views."""
    co = cfg()
    try:
        import cryptography  # noqa
        cryp = "cryptography"
    except ImportError:
        cryp = "openssl" if subprocess.run(["which", "openssl"], capture_output=True).returncode == 0 else None
    srcs = token_sources(); now = int(time.time())
    editors = [{"name": s["name"], "valid": bool(s.get("exp") and s["exp"] > now),
                "seconds_left": (s["exp"]-now) if s.get("exp") else None,
                "running": bool(s["running"])} for s in srcs]
    backend = None
    if co.get("network_enabled") and srcs:
        st, _ = last_session_state()
        e, _p, err, _ = fetch_live(st.get("ccVersion"))
        backend = {"reachable": bool(e), "error": None if e else str(err)}
    exts = []
    for g in ("~/.vscode/extensions", "~/.cursor/extensions"):
        d = os.path.expanduser(g)
        if os.path.isdir(d):
            exts += [x for x in os.listdir(d) if x.startswith("kickbacksai.kickbacks-ai")]
    jobs = {}
    for label, feat in ((SAMPLER_LABEL, "sampler"), (NOTIFY_LABEL, "notifications")):
        loaded = subprocess.run(["launchctl", "list", label], capture_output=True).returncode == 0
        want = feature(feat)
        jobs[feat] = {"config": want, "loaded": loaded, "ok": loaded == want}
    return {"crypto_backend": cryp, "config_path": CFG_PATH,
            "network": bool(co.get("network_enabled")), "editors": editors,
            "backend": backend, "extension": sorted(set(exts)), "jobs": jobs}

def cmd_doctor():
    r = _doctor_report()
    if flag("--json"):
        r["ok"] = bool(r["crypto_backend"]) and (r["backend"] is None or r["backend"]["reachable"]) \
                  and all(j["ok"] for j in r["jobs"].values())
        return emit(r)
    print(" "+BOLD("kickback doctor")); print(" "+DIM("─"*56))
    print(f"  crypto backend  {r['crypto_backend'] or RED('NONE')}")
    print(f"  config          {r['config_path']}")
    print(f"  network         {GREEN('on') if r['network'] else YELLOW('off')}")
    if not r["editors"]:
        print("  editors         "+RED("none signed in")+DIM("  (sign in via the extension)"))
    for ed in r["editors"]:
        left = f"{ed['seconds_left']/60:.0f}m" if ed["seconds_left"] is not None else "?"
        ok = GREEN("valid "+left) if ed["valid"] else RED("expired")
        print(f"  editor:{ed['name'].ljust(7)} {ok}  {DIM('running' if ed['running'] else 'closed')}")
    if r["backend"] is not None:
        b = r["backend"]
        print(f"  backend         {GREEN('reachable') if b['reachable'] else RED('error: '+str(b['error']))}")
    print(f"  extension       {(GREEN(', '.join(r['extension'])) if r['extension'] else YELLOW('not found in VS Code/Cursor'))}")
    for feat, j in r["jobs"].items():
        sync = GREEN("ok") if j["ok"] else YELLOW(f"config={j['config']} job={'loaded' if j['loaded'] else 'absent'}")
        print(f"  job:{feat.ljust(11)} {sync}")
    print(" "+DIM("─"*56))
    print(DIM("  `kickback about` explains each item and what could change it."))

# ---------- rewire: restore the spinner/statusline keys -------------------
def _desired_wiring():
    """The (spinnerVerbs, statusLine) the extension would write — used to restore
    them if another settings.json writer (Claude Code, hooks) strips them."""
    ad = read_json(path_ad()) or {}
    spin = {"mode": "replace", "verbs": [ad["adText"]]} if ad.get("adText") else None
    script = os.path.join(VA(), "vibe-ads-statusline.mjs")
    sl = {"type": "command", "command": f'node "{script}"', "padding": 0} if os.path.exists(script) else None
    return spin, sl

def rewire(quiet=False, only_if_missing=True):
    """Restore the spinner/statusline keys in ~/.claude/settings.json. Touches
    ONLY those two keys, bails if the file isn't valid JSON, writes atomically.
    Returns {rewired, changed, reason} so callers can report the outcome."""
    path = expand(cfg()["claude_settings"])
    try:
        with open(path) as f: data = json.load(f)
    except FileNotFoundError:
        if not quiet: print("settings.json not found — nothing to rewire.")
        return {"rewired": False, "changed": [], "reason": "settings.json not found"}
    except Exception:
        if not quiet: print(RED("settings.json isn't valid JSON — leaving it untouched."))
        return {"rewired": False, "changed": [], "reason": "settings.json not valid JSON"}
    spin, sl = _desired_wiring()
    if spin is None and sl is None:
        if not quiet:
            print(DIM("nothing to restore — the extension isn't serving (its statusline script/ad are gone)."))
            print(DIM("open a VS Code window; spinner/statusline are wired only while it's active."))
        return {"rewired": False, "changed": [], "reason": "extension not serving"}
    changed = []
    have_spin = bool((data.get("spinnerVerbs") or {}).get("verbs"))
    have_sl = "vibe-ads-statusline" in ((data.get("statusLine") or {}).get("command") or "")
    if spin is not None and (not only_if_missing or not have_spin) and data.get("spinnerVerbs") != spin:
        data["spinnerVerbs"] = spin; changed.append("spinner")
    if sl is not None and (not only_if_missing or not have_sl) and data.get("statusLine") != sl:
        data["statusLine"] = sl; changed.append("statusline")
    if not changed:
        if not quiet: print("already wired ✓ — nothing to do.")
        return {"rewired": False, "changed": [], "reason": "already wired"}
    tmp = path + ".kbtmp"
    with open(tmp, "w") as f: json.dump(data, f, indent=2)
    os.replace(tmp, path)                       # atomic — never leaves a half-written file
    if not quiet: print("rewired:", ", ".join(changed))
    return {"rewired": True, "changed": changed, "reason": "rewired"}

# ---------- onboarding: setup + login -------------------------------------
KICKBACKS_EXT = "Kickbacksai.kickbacks-ai"
CLAUDE_EXT = "anthropic.claude-code"
def _find_code():
    """Return path to the VS Code `code` CLI, or None. (Kickbacks ships on the
    MS Marketplace, which the VS Code CLI uses; Cursor uses Open VSX and can't
    install it, so we target VS Code here.)"""
    p = shutil.which("code")
    if p: return p
    appbin = "/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code"
    return appbin if os.path.exists(appbin) else None

def _store_secret_exact(ed, which, token_str):
    """Write an editor secret by its exact VS Code key (INSERT OR REPLACE so it
    works cold-start, before the extension has ever stored anything)."""
    key = _key(ed["keychain_service"])
    keyname = 'secret://{"extensionId":"kickbacksai.kickbacks-ai","key":"kickbacks.%s"}' % which
    val = json.dumps({"type": "Buffer", "data": list(_enc(token_str, key))})
    con = sqlite3.connect(expand(ed["state_db"]), timeout=5)
    try:
        con.execute("INSERT OR REPLACE INTO ItemTable(key,value) VALUES(?,?)", (keyname, val))
        con.commit()
    finally:
        con.close()

def cmd_login():
    """Full CLI sign-in: run Kickbacks' device flow, open the browser for the
    Google click, poll for tokens, and write them into the editor's store."""
    print(YELLOW("⚠  CLI login is experimental") +
          DIM(" — the official “Kickbacks: Sign in” button in your editor is recommended."))
    if not flag("--yes"):
        if sys.stdin.isatty():
            try:
                if input("   Continue anyway? [y/N] ").strip().lower() not in ("y", "yes"):
                    print("aborted."); return
            except (EOFError, KeyboardInterrupt):
                print("\naborted."); return
        else:
            print(DIM("   non-interactive: re-run with --yes to confirm.")); return
    eds = cfg()["editors"]
    target = (next((e for e in eds if e["name"] == "Code" and os.path.exists(expand(e["state_db"]))), None)
              or next((e for e in eds if os.path.exists(expand(e["state_db"]))), None) or eds[0])
    if not os.path.exists(expand(target["state_db"])):
        print(RED("No editor store found — run `kickback setup` (or open VS Code once) first.")); return

    class _NoRedirect(urllib.request.HTTPRedirectHandler):
        def redirect_request(self, *a, **k): return None
    opener = urllib.request.build_opener(_NoRedirect)
    loc = None
    try:
        r = opener.open(BACKEND() + "/v1/auth/extension/start", timeout=15)
        loc = r.headers.get("Location")
    except urllib.error.HTTPError as e:
        loc = e.headers.get("Location")
    except Exception as ex:
        print(RED(f"login: couldn't reach the backend ({type(ex).__name__})")); return
    if not loc:
        print(RED("login: server returned no auth URL")); return
    state = urllib.parse.parse_qs(urllib.parse.urlparse(loc).query).get("state", [None])[0]
    if not state:
        print(RED("login: no state in auth URL")); return

    print("→ opening your browser to sign in with Google…")
    subprocess.run(["open", loc])
    print(DIM("  waiting for you to finish in the browser (up to ~4 min)…"))
    tok = ref = None
    for _ in range(120):
        time.sleep(2)
        try:
            with urllib.request.urlopen(
                BACKEND() + "/v1/auth/extension/poll?state=" + urllib.parse.quote(state), timeout=12) as r:
                j = json.loads(r.read())
        except Exception:
            continue
        if j.get("access_token"):
            tok, ref = j["access_token"], j.get("refresh_token"); break
        sys.stdout.write("."); sys.stdout.flush()
    print()
    if not tok:
        print(RED("login timed out — re-run `kickback login`.")); return

    try:
        _store_secret_exact(target, "access", tok)
        if ref: _store_secret_exact(target, "refresh", ref)
    except Exception as ex:
        print(RED(f"signed in, but couldn't store the token ({type(ex).__name__})."))
        print(DIM("  Close the editor (so it isn't holding the database) and re-run `kickback login`."))
        return
    email = _jwt_claims(tok).get("email", "?")
    print(GREEN(f"✓ signed in as {email}") + DIM(f"  (token stored for {target['name']})"))
    e, _p, _err, _s = fetch_live(None)
    if e:
        print(f"  lifetime {GREEN(usd(e.get('lifetime_micros', 0)))} · today {GREEN(usd(e.get('today_micros', 0)))}")
    print(DIM("  `kickback` now works from the terminal. If your editor is open, reload its"))
    print(DIM("  window (Cmd+Shift+P → Reload Window) so the ad spinner picks up the sign-in."))

def cmd_setup():
    print(BOLD("kickback setup — installing your editor + extensions\n"))
    code = _find_code()
    if not code:
        if shutil.which("brew"):
            print("→ VS Code not found — installing (brew install --cask visual-studio-code)…")
            subprocess.run(["brew", "install", "--cask", "visual-studio-code"])
            code = _find_code()
        else:
            print(RED("VS Code not found and Homebrew isn't installed."))
            print("  Install VS Code from https://code.visualstudio.com, then re-run `kickback setup`.")
            return
    if not code:
        print(RED("Could not locate the `code` CLI after install.")); return
    for ext, label in ((KICKBACKS_EXT, "Kickbacks"), (CLAUDE_EXT, "Claude Code")):
        print(f"→ installing {label} extension…")
        subprocess.run([code, "--install-extension", ext])
    subprocess.run(["open", "-a", "Visual Studio Code"])
    print()
    print(GREEN("Almost done — one click left") + DIM("  (Google sign-in can't be scripted):"))
    print("  1. In VS Code, click " + BOLD("\"Kickbacks: Sign in\"") + " in the status bar → sign in with Google")
    print("  2. Back here, verify with:  " + BOLD("kickback doctor"))
    print()
    print(DIM("  (then `kickback` shows your live earnings — no need to reopen the editor)"))

def cmd_init():
    fresh = not os.path.exists(CFG_PATH)
    cfg()  # creates it if missing
    print(GREEN("✓ ")+("created" if fresh else "found")+f" config: {CFG_PATH}")
    print(DIM("  Safe defaults: read-only status + earnings on; sampler/notifications/refresh off."))
    print()
    show_config()
    print()
    print(DIM("  Next:  kickback about   (what it touches)   ·   kickback doctor   (live check)"))

# ---------- launchd / alias / notify --------------------------------------
def _plist(label, prog_args, interval=None, calendar=None, errp=None):
    p = os.path.expanduser(os.path.join("~/Library/LaunchAgents", label+".plist"))
    os.makedirs(os.path.dirname(p), exist_ok=True)
    args = "".join(f"<string>{a}</string>" for a in prog_args)
    sched = f"<key>StartInterval</key><integer>{interval}</integer>" if interval else ""
    if calendar:
        sched = (f"<key>StartCalendarInterval</key><dict><key>Hour</key><integer>{calendar[0]}</integer>"
                 f"<key>Minute</key><integer>{calendar[1]}</integer></dict>")
    runatload = "<key>RunAtLoad</key><true/>" if interval else ""
    errx = f"<key>StandardErrorPath</key><string>{errp}</string>" if errp else ""
    with open(p, "w") as f:
        f.write(f'''<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict><key>Label</key><string>{label}</string>
<key>ProgramArguments</key><array>{args}</array>{sched}{runatload}{errx}</dict></plist>
''')
    subprocess.run(["launchctl", "unload", p], stderr=subprocess.DEVNULL)
    r = subprocess.run(["launchctl", "load", p], capture_output=True, text=True)
    return p, r.returncode == 0, r.stderr.strip()

def install_sampler():
    os.makedirs(VA(), exist_ok=True)
    p, ok, e = _plist(SAMPLER_LABEL, [SELF, "log", "--quiet"], interval=60, errp=os.path.join(VA(), "sampler.err"))
    print(f"  sampler job loaded ({p})" if ok else f"  load failed: {e}")
def uninstall_sampler():
    p = os.path.expanduser(os.path.join("~/Library/LaunchAgents", SAMPLER_LABEL+".plist"))
    subprocess.run(["launchctl", "unload", p], stderr=subprocess.DEVNULL)
    try: os.remove(p); print("  sampler job removed")
    except FileNotFoundError: pass
def install_notify():
    hh = int(cfg().get("notify_hour", 21))
    p, ok, e = _plist(NOTIFY_LABEL, [SELF, "notify"], calendar=(hh, 0), errp=os.path.join(VA(), "notify.err"))
    print(f"  daily notification at {hh:02d}:00 ({p})" if ok else f"  load failed: {e}")
def uninstall_notify():
    p = os.path.expanduser(os.path.join("~/Library/LaunchAgents", NOTIFY_LABEL+".plist"))
    subprocess.run(["launchctl", "unload", p], stderr=subprocess.DEVNULL)
    try: os.remove(p); print("  daily notification removed")
    except FileNotFoundError: pass
def install_alias():
    line = f"alias kb='{APP}'"
    try: existing = open(ZSHRC).read() if os.path.exists(ZSHRC) else ""
    except Exception: existing = ""
    if "alias kb=" in existing: print("kb alias already present in ~/.zshrc"); return
    with open(ZSHRC, "a") as f: f.write(f"\n# Kickbacks status shortcut\n{line}\n")
    print("Added `kb` alias to ~/.zshrc — open a new shell or `source ~/.zshrc`.")

def notify():
    state, _ = last_session_state()
    e, p, _err, _src = fetch_live(state.get("ccVersion"))
    rows = read_hist(); per, _ = attribute(rows)
    top = max(per.items(), key=lambda kv: kv[1])[0] if per else None
    if e:
        sub = f"{usd(e.get('today_micros',0))} today · {usd(e.get('lifetime_micros',0))} lifetime"
        msg = f"Top ad: {top[:40]}" if top else "Keep coding to earn."
    else:
        sub, msg = "earnings unavailable", "Open your editor to refresh sign-in."
    esc = lambda s: s.replace('"', '\\"')
    rc = subprocess.run(["osascript", "-e", f'display notification "{esc(msg)}" with title "Kickbacks" subtitle "{esc(sub)}"']).returncode
    if flag("--json"): return emit({"sent": rc == 0, "subtitle": sub, "message": msg})
    print(sub, "·", msg)

def watch():
    try: secs = max(1, int(positional(1, "5") or 5))    # tolerate `watch abc` / `watch 0`
    except (ValueError, TypeError): secs = 5
    try:
        while True:
            d = gather(); e, p, err, src = fetch_live(d["cc_version"])
            sys.stdout.write("\033[2J\033[H"); print(render(d, e, p, err, src))
            print(DIM(f"\n  ↻ every {secs}s · Ctrl-C to exit · {time.strftime('%H:%M:%S')}"))
            sys.stdout.flush(); time.sleep(secs)
    except KeyboardInterrupt:
        print()

def refresh_cmd():
    if not feature("token_refresh"):
        print("token_refresh is off (safe default). Enable with `kickback enable token_refresh`."); return
    srcs = token_sources()
    if not srcs: print("No signed-in editor."); return
    for s in srcs:
        if s["running"]:
            if not flag("--force"):
                print(f"{s['name']} is running — skipping (it refreshes itself; --force to override)."); continue
            # --force on a running editor rotates the server-side refresh token and can sign it out.
            try:
                ans = input(f"{s['name']} is running — refreshing may sign it out. Continue? [y/N] ").strip().lower()
            except EOFError:
                ans = ""
            if ans not in ("y", "yes"):
                print("  skipped."); continue
        nt = _do_refresh(s, quiet=False)
        if nt:
            cl = _jwt_claims(nt); print(f"  {s['name']}: new token valid ~{(cl.get('exp',0)-int(time.time()))/60:.0f}m")

# ---------- self-update / version nudge -----------------------------------
def _vtuple(s):
    try: return tuple(int(x) for x in str(s).split(".")[:3])
    except Exception: return (0,)

def _install_method():
    """How this copy was installed — 'brew' (managed by Homebrew) or 'web' (install.sh)."""
    return "brew" if "/Cellar/" in os.path.realpath(SELF) else "web"

def _fetch_manifest(timeout=3):
    """Full version manifest dict from our Pages site, or None. Never raises.
    Shape: {version, app_version, app_url, app_upgrade, notes_url, install}."""
    try:
        req = urllib.request.Request(UPDATE_MANIFEST_URL, headers={"user-agent": f"{APP}/{VERSION}"})
        with urllib.request.urlopen(req, timeout=timeout) as r:
            j = json.loads(r.read())
        return j if isinstance(j, dict) else None
    except Exception:
        return None

def _update_cache_path(): return os.path.join(CONFIG_DIR, "update-check.json")
def _cached_manifest():
    """Throttled manifest lookup — hits the network at most once/day, else uses cache.
    Never raises. Returns the manifest dict or None."""
    try:
        c = read_json(_update_cache_path()) or {}
        fresh = isinstance(c.get("checked_at_ms"), (int, float)) and (time.time()*1000 - c["checked_at_ms"] < 86_400_000)
        if isinstance(c.get("manifest"), dict) and fresh:
            return c["manifest"]
        m = _fetch_manifest(timeout=3)
        if m:
            try:
                os.makedirs(CONFIG_DIR, exist_ok=True)
                with open(_update_cache_path(), "w") as f:
                    json.dump({"checked_at_ms": int(time.time()*1000), "manifest": m}, f)
            except OSError: pass
        return m or (c.get("manifest") if isinstance(c.get("manifest"), dict) else None)
    except Exception:
        return None

def _update_nudge():
    """One-line 'newer version out' string, or None. Opt-in (update_check) + respects network."""
    if _demo_on() or not feature("update_check") or not cfg().get("network_enabled", True):
        return None
    latest = (_cached_manifest() or {}).get("version")
    if latest and _vtuple(latest) > _vtuple(VERSION):
        return f"↑ kickback {latest} is out (you have {VERSION}) — run `kickback update`"
    return None

def _update_block(app_version=None):
    """The `update` object embedded in status --json — what the menu bar app reads.
    None unless update_check is on and the manifest is reachable/cached. When the
    caller passes its own app_version, the block also resolves app_update_available."""
    if _demo_on() or not feature("update_check") or not cfg().get("network_enabled", True):
        return None
    m = _cached_manifest()
    if not m: return None
    cli_latest, app_latest = m.get("version"), m.get("app_version")
    block = {"cli_current": VERSION, "cli_latest": cli_latest,
             "cli_update_available": bool(cli_latest and _vtuple(cli_latest) > _vtuple(VERSION)),
             "app_latest": app_latest, "app_url": m.get("app_url"), "app_upgrade": m.get("app_upgrade")}
    if app_version is not None:
        block["app_current"] = app_version
        block["app_update_available"] = bool(app_latest and _vtuple(app_latest) > _vtuple(app_version))
    return block

def cmd_update():
    """Self-update. Checks our version manifest, then upgrades via the same channel
    it was installed from. `--check` reports without changing anything. Pass
    `--app-version X` (the menu bar app does) to also learn if the .app is stale."""
    m = _fetch_manifest(timeout=8) or {}
    latest, method = m.get("version"), _install_method()
    available = bool(latest and _vtuple(latest) > _vtuple(VERSION))
    app_cur, app_latest = opt("--app-version"), m.get("app_version")
    app_available = bool(app_cur and app_latest and _vtuple(app_latest) > _vtuple(app_cur))
    if flag("--json"):
        out = {"current": VERSION, "latest": latest, "update_available": available, "method": method}
        if app_cur is not None or app_latest is not None:
            out["app"] = {"current": app_cur, "latest": app_latest, "update_available": app_available,
                          "url": m.get("app_url"), "upgrade": m.get("app_upgrade")}
        return emit(out)
    if not m:
        print(YELLOW("couldn't reach the update server")+DIM(" — showing the manual command below."))
    elif available:
        print(f"update available: {DIM(VERSION)} → {GREEN(latest)}")
    else:
        print(GREEN(f"✓ up to date — kickback {VERSION} is the latest."))
    if available or not m:
        if method == "brew":
            cmd = ["brew", "upgrade", "kickback"]
            if flag("--check"): print(DIM("  upgrade with:  "+" ".join(cmd)))
            else: print(DIM("  running: "+" ".join(cmd))); subprocess.run(cmd)
            print(DIM("  menu bar app? also:  brew upgrade --cask kickbacks-bar"))
        else:
            if flag("--check"): print(DIM("  upgrade with:  "+INSTALL_ONELINER))
            else: print(DIM("  running: "+INSTALL_ONELINER)); subprocess.run(INSTALL_ONELINER, shell=True)
    if app_available:
        print(f"menu bar app: {DIM(app_cur)} → {GREEN(app_latest)}"
              + DIM(f"  download: {m.get('app_upgrade') or m.get('app_url')}"))

USAGE = """kickback — Kickbacks.ai status, earnings & ad history in the terminal

  kickback                 status + live earnings + current ad
  kickback earnings        lifetime/today, velocity, derived rate
  kickback history|ads     ads seen, counts, derived $ per ad
  kickback daily|chart     per-day earnings sparkline
  kickback weekly|week     per-ISO-week earnings rollup
  kickback monthly|month   per-month earnings rollup
  kickback yearly|year     per-year earnings rollup
  kickback auth            per-editor token: account, expiry
  kickback watch [secs]    live auto-refreshing view
  kickback notify          fire a macOS notification now

  kickback config          show settings + feature toggles
  kickback config <feat>   full context for one setting + how to change it
  kickback config set <key> <value>   edit a value (notify_hour, backend, data dir…)
  kickback config edit     open the config file in $EDITOR
  kickback enable  <feat>  network | sampler | notifications | token_refresh | autorewire | update_check
  kickback disable <feat>
  kickback update          upgrade to the latest CLI (via brew or the installer)
  kickback rewire          restore spinner/statusline keys if they got stripped
  kickback refresh [--force]   mint a fresh token (needs token_refresh, editor closed)
  kickback about           what it reads/sends + what could break
  kickback doctor          live diagnostics
  kickback setup           install VS Code + Kickbacks + Claude Code extensions
  kickback login           sign in from the CLI (opens browser for Google, writes token)
  kickback init            create config with safe defaults
  kickback install-alias   add `kb` shortcut to ~/.zshrc

Flags: --json  --plain  --offline  --no-log

Reading `history`:  n× = times an ad was sampled in rotation · $ = est. earned
  from it (your ~50% share, approx, not billed) · w = its rotation weight.
  Most rows show no $ (an ad only credits when viewed past the ~15s threshold).
  Your real total is `kickback earnings`.
"""

def main():
    sub = ARGS[0] if ARGS and not ARGS[0].startswith("-") else None
    if sub in ("version", "--version", "-V") or flag("--version"):
        if flag("--json"): emit({"app": APP, "version": VERSION}); return
        print(f"{APP} {VERSION}"); return
    if sub in ("help", "-h", "--help"): print(USAGE); return
    if sub in ("history", "ads"): return show_history()
    if sub == "earnings": return show_earnings()
    if sub in ("daily", "chart"): return show_daily()
    if sub in ("weekly", "week"): return show_period("weekly")
    if sub in ("monthly", "month"): return show_period("monthly")
    if sub in ("yearly", "year"): return show_period("yearly")
    if sub == "auth": return show_auth()
    if sub == "config":
        a = positional(1)
        if a == "edit": return config_edit()
        if a == "set": return config_set(positional(2), positional(3))
        if a == "get": return config_get(positional(2))
        if a: return config_detail(a)
        return show_config()
    if sub == "enable": return set_feature(positional(1, ""), True)
    if sub == "disable": return set_feature(positional(1, ""), False)
    if sub == "about": return cmd_about()
    if sub == "doctor": return cmd_doctor()
    if sub == "init": return cmd_init()
    if sub == "setup": return cmd_setup()
    if sub == "login": return cmd_login()
    if sub == "rewire":
        res = rewire(quiet=flag("--json"), only_if_missing=False)
        if flag("--json"): emit(res)
        return
    if sub == "refresh": return refresh_cmd()
    if sub == "watch": return watch()
    if sub in ("update", "upgrade", "self-update"): return cmd_update()
    if sub == "notify": return notify()
    if sub == "log":
        # the 60s sampler tick — also the cadence for autorewire (only-if-missing)
        if feature("autorewire") and not _demo_on():
            try: rewire(quiet=True, only_if_missing=True)
            except Exception: pass
        return log_ad(quiet=flag("--quiet"))
    if sub == "install-alias": return install_alias()
    if sub in ("install-sampler", "uninstall-sampler", "install-notify", "uninstall-notify"):
        return {"install-sampler": lambda: set_feature("sampler", True),
                "uninstall-sampler": lambda: set_feature("sampler", False),
                "install-notify": lambda: set_feature("notifications", True),
                "uninstall-notify": lambda: set_feature("notifications", False)}[sub]()
    # default: status
    d = gather()
    e, p, err, src = (None, None, "offline", None) if flag("--offline") else fetch_live(d["cc_version"])
    if not flag("--no-log") and not flag("--offline"):     # --offline: no keychain, no network, no writes
        try: log_ad(quiet=True, prefetched=(e, p))          # reuse the fetch above — don't hit the backend twice
        except Exception: pass
    if flag("--json"):
        d.pop("_vc", None)
        out = {**d, "earnings": e, "earnings_error": err, "token_source": (src or {}).get("name")}
        try:
            upd = _update_block(opt("--app-version"))     # populated only when update_check is on
            if upd: out["update"] = upd
        except Exception: pass
        print(json.dumps(out, indent=2))
    else:
        print(render(d, e, p, err, src))
        try:
            n = _update_nudge()
            if n: print(DIM("  "+n))
        except Exception: pass

if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        sys.exit(130)
    except BrokenPipeError:
        sys.exit(0)                                        # e.g. `kickback history | head`
    except Exception as ex:
        # "degrades gracefully — never crashes": no raw traceback for end users.
        print(f"kickback: unexpected error ({type(ex).__name__}: {ex})", file=sys.stderr)
        print("  try `kickback doctor`, or report it at github.com/gabeperez/kickback-cli", file=sys.stderr)
        sys.exit(1)
